// Copyright 2012 Jay Young. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview
 *
 * Provides a visitor class that finds and documents class properties.
 */

var {Token, JSDocInfo} = com.google.javascript.rhino,
    MethodV = require('rdoc/visitors/method').MethodV,
    DocInfo = require('rdoc/docinfo').DocInfo;
    


/**
 * A node visitor that finds and documents class properties.
 *
 * For now, this visitor only understands assignments to properties on the
 * "this" object inside constructors or prototype methods, such as:
 *
 * // Constructor
 * some.Class = function() {
 *   this.property = value;
 * };
 *
 *    and
 *
 * // Prototype methods
 * some.Class.prototype.method = function() {
 *   this.property = value;
 * };
 *
 * @constructor
 * @implements {Documentor.Visitor}
 */
var PropertyV = exports.PropertyV = function() {  
  /**
   * The next visitor in the chain.
   *
   * @type {Documenter.Visitor}
   * @private
   */
  this.nextVisitor_ = null;

  /**
   * @type {Array.<string>}
   * @private
   */
  this.suppressVisProperties_ = [];
};


/**
 * @param {Documenter.Visitor} visitor The next visitor in the chain.
 */
PropertyV.prototype.setNext = function(visitor) {
  this.nextVisitor_ = visitor;
};


/**
 * Determine if a node represents an assignment to a class property.
 *
 * @param {Node} node The node to visit.
 * @parma {DocInfo} docInfo The page's documentation info object.
 * @param {JSDocInfo} jsdoc The jsdoc for the given node or null if no
 *    doc comments were attached.
 */
PropertyV.prototype.visit = function(node, docInfo, jsdoc) {
  var prop = isProperty(node);
  if (prop && (! MethodV.prototype.isMethodAssignment(node, docInfo))) {
    var {className, propName, onThis} = prop;

    if (className &&
        (docInfo.isDeclaredClass(className) ||
         docInfo.isDeclaredInterface(className))
       ) {
      var desc = docInfo.isDeclaredClass(className) ?
                    docInfo.getClass(className) :
                    docInfo.getInterface(className),

          properties = desc.properties;

      if (!properties) {
        properties = desc.properties = {};
      }

      var description, type, visibility;

      var inferVis = true;
      if (jsdoc) {
        type = docInfo.resolveType(jsdoc.getType());
        description = jsdoc.getBlockDescription();
        visibility = DocInfo.visibility(jsdoc.getVisibility());

        if (jsdoc.getSuppressions().contains('underscore')) {
          this.suppressVisProperties_.push(propName);
          inferVis = false;
        }
      }
      
      if (this.suppressVisProperties_.indexOf(propName) > -1) {
        inferVis = false;
      }

      // Infer visibility, if necessary.
      if (! visibility && inferVis) {
        var underscore = propName.indexOf('_');
        if (underscore === 0 ||
           (underscore === (propName.length - 1))) {
          visibility = DocInfo.visibility(JSDocInfo.Visibility.PRIVATE);
        }
      }

      prop = properties[propName];
      
      /*
        Declarations on the object instance overrule declarations on the
        prototype.
       
        If previously declared on the prototype {
          if currently being declared on the object {
            set properties without warnings.
          }
          else {
            check and warn.
          }
        }
        else if previously declared on the object {
          if currently being declared on the prototype {
            Don't.
          }
          else {
            check and warn.
          }
        }
        else if not previously declared {
          set properties.
        }
      */
      
      if (prop && (! prop.onThis)) {
        if (onThis) {
          properties[propName] =
              createPropertyDescriptor(propName, type || prop.type,
                                       visibility || prop.visibility,
                                       description || prop.desc, onThis);
        }
        else {
          assertNoConflict(className, prop, type, visibility, description);
          properties[propName] =
              createPropertyDescriptor(propName, type || prop.type,
                                       visibility || prop.visibility,
                                       description || prop.desc, onThis);
              
        }
      }
      else if (prop && prop.onThis) {
        if (onThis) {
          assertNoConflict(className, prop, type,
                           visibility, description);
          properties[propName] =
              createPropertyDescriptor(propName, type || prop.type,
                                       visibility || prop.visibility,
                                       description || prop.desc, onThis);
        }
      }
      else if (! prop) {
        properties[propName] =
            createPropertyDescriptor(propName, type,
                                     visibility, description, onThis);
      }
      else {
        throw Error("Shouldn't be able to get here.");
      }
    }
  }

  if (this.nextVisitor_) {
    this.nextVisitor_.visit(node, docInfo, jsdoc);
  }
};


/**
 * Determines whether a node is an assignment to a class member, whether inside
 * a method or a direct assignment to the prototype property of a class.
 *
 * @param {Node} node The node in question.
 * @return {({className: string, propName: string,
 *            onThis: boolean}|null)} A record object containing the class,
 *            property name, and whether or not the property is defined on this
 *            object instance (as opposed to on the prototype), or null if this
 *            is not a property assignment.
 */
function isProperty(node) {
  var className, propName, onThis,
      name;

  if (node.getType() === Token.ASSIGN &&
     (name = node.getFirstChild().getQualifiedName()) &&
     (name.indexOf('this') === 0)) {

    onThis = true;
    var nameParts = name.split('.');
    
    if (nameParts.length > 2) {
      // This isn't an assignment to this.property, but is something like
      //     this.property.property
      // We're not documenting this case.
      
      return null;
    }
    propName = nameParts[1];

    // Find the ancestor assignment node to figure out what class we're inside.
    var ancestors = node.getAncestors().iterator(),
        ancestor;
    while (ancestors.hasNext()) {
      ancestor = ancestors.next();

      if (ancestor.getType() === Token.ASSIGN) {

        // Is this ancestor a constructor function?
        var jsdoc = ancestor.getJSDocInfo();
        if (jsdoc && jsdoc.isConstructor()) {
          className = ancestor.getFirstChild().getQualifiedName();
          break;
        }
        
        // Is this ancestor a method assignment to the prototype?
        else if (ancestor.getFirstChild()
                         .getQualifiedName().indexOf('.prototype.') > -1) {
          
          className = ancestor.getFirstChild().getQualifiedName()
                              .split('.prototype.')[0];
          break;
        }
      }
    }
  }

  else if (node.getType() === Token.ASSIGN &&
      node.getChildAtIndex(1).getType() !== Token.FUNCTION &&
      (name = node.getFirstChild().getQualifiedName()) &&
      name.indexOf('.prototype.') > -1) {

    onThis = false;
    var name = node.getFirstChild().getQualifiedName().split('.prototype.');
    className = name[0];
    propName = name[1];
  }

  // Required interface properties are declared as prototype members, but are
  // not assignment nodes.  Instead, they are top-level GETPROP nodes whose
  // parent nodes are EXPR_RESULT nodes. For example:
  //
  // /**
  //  * A type property representing the change.
  //  * @type {string}
  //  */
  // relief.auth.AuthChangeEvent.prototype.type;

  else if (node.getType() === Token.GETPROP &&
           node.getParent().getType() === Token.EXPR_RESULT) {

    name = node.getQualifiedName();
    if (name.indexOf('.prototype.') > -1) {
      var names = name.split('.prototype.');

      className = names[0];
      propName = names[1];
      onThis = false;
    }
  }

  if (className && propName) {
    return {
      className: className,
      propName: propName,
      onThis: onThis
    };
  }
  else {
    return null;
  }
}


/**
 * Assert that if a property was declared multiple times, it was not done with
 * conflicting information.
 *
 * @param {string} className The name of the class being defined on.
 * @param {PropertyDescriptor} prop The original property descriptor.
 * @param {JSTypeExpression} type The new type.
 * @param {JSDocInfo.Visibility} visibility The new visibility.
 * @param {string} description The new description.
 *
 * @throws {Error} If declared with conflicting information.
 */
function assertNoConflict(className, prop, type, visibility, description) {
  // Make sure that the property isn't declared multiple times with
  // conflicting information.

  if (prop.type && type && !prop.type.equals(type)) {

    throw Error('Property ' + className + '.' + prop.name + ' declared ' +
                'multiple times with different type expressions.');
  }
  
  if (prop.visibility && visibility && prop.visibility !== visibility) {    
    throw Error('Property ' + className + '.' + prop.name + ' declared ' +
                'multiple times with different visibilities.');
  }
  
  if (prop.desc && description && prop.desc !== description) {
    throw Error('Property ' + className + '.' + prop.name + ' declared ' +
                'multiple times with different descriptions.');
  }
}


/**
 * Create a property descriptor.
 * @param {string} name The property name.
 * @param {?string} type The type expression.
 * @param {(number|JSDocInfo.Visibility)} visibility The property's visibility.
 * @param {string} description The property's description.
 * @param {boolean} onThis Whether the property is defined on the instance
 *    itself (via "this") or on the prototype (if false).
 *
 * @return {PropertyDescriptor} The property descriptor.
 */
function createPropertyDescriptor(name, type, visibility, description, onThis) {
  var prop = {};
  prop.name = name;

  if (visibility instanceof com.google.javascript.rhino.JSDocInfo.Visibility) {
    visibility = DocInfo.visibility(visibility);
  }

  prop.type = type;
  prop.desc = description || '';
  prop.visibility = visibility;

  prop.onThis = onThis;
  
  return prop;
}
